.NET 處理日期時間不是只有一個 DateTime。BCL 提供的這幾個型別各自對應不同的語意,選錯型別造成的 bug 通常不會在開發機出現 —— 它會在使用者跨到另一個時區、或系統跨過日光節約時間切換點時才浮出來。
型別速查
| 型別 | 語意 | 典型場景 |
|---|---|---|
DateTimeOffset | 時間點 + 相對 UTC 的偏移量,明確識別單一瞬間 | 記錄、傳輸、儲存時間戳;官方建議的預設型別 |
DateTime | 時間點,只帶一個 Kind(Utc / Local / Unspecified) | 抽象日期時間、確定只用 UTC、或時區無關的運算 |
TimeZoneInfo | 時區本身(含日光節約的調整規則) | 時區間轉換、DST 感知的算術 |
TimeSpan | 時間長度,不是時間點 | 兩個時間的差、逾時、間隔 |
DateOnly | 只有日期(.NET 6+) | 生日、到期日、合約日 |
TimeOnly | 只有時間(.NET 6+) | 營業時間、預約時段 |
TimeProvider | 時間來源的抽象(.NET 8+) | 讓時間相依的邏輯可測試 |
順序是有意的。習慣上 DateTime 被當成預設選擇,但官方文件的建議相反 —— DateTimeOffset 涵蓋 DateTime 的全部功能再加上時區感知,且值一律明確識別單一時間點,所以「請考慮 DateTimeOffset 作為應用程式開發的預設日期和時間類型」。DateTime 適用的是它真的夠用的那些場景:處理抽象的日期時間、資料本來就沒有時區資訊、或確定全程只用 UTC。
DateTime
內部以 tick(100 奈秒)自 0001-01-01 起算。加減用 AddDays / AddHours 這類方法,格式化用 ToString 搭配自訂 pattern。
DateTime now = DateTime.Now; // 本機時區
DateTime utcNow = DateTime.UtcNow; // UTC
DateTime specific = new DateTime(2023, 9, 30, 14, 30, 0);
DateTime tomorrow = now.AddDays(1);
string formatted = now.ToString("yyyy-MM-dd HH:mm:ss");關鍵限制:DateTime 對時區的處理很弱。Kind 只分得出「UTC / 本機 / 未指定」三種狀態,記不住「這個時間屬於哪個時區」。一旦資料要跨區域流動,光靠 DateTime 很容易出現算得出來但算錯的情況。
DateTimeOffset
在時間點之外多帶一個相對 UTC 的偏移量,所以同一個瞬間在不同地點記錄下來仍可正確比較。
DateTimeOffset nowWithOffset = DateTimeOffset.Now;
DateTimeOffset inNY = new DateTimeOffset(2023, 9, 30, 9, 0, 0, TimeSpan.FromHours(-4));
DateTimeOffset inTokyo = new DateTimeOffset(2023, 9, 30, 23, 0, 0, TimeSpan.FromHours(9));需要儲存或傳輸時間、又想保留當地時間資訊時用它。
但要清楚它的界線:offset 不等於時區。+09:00 記得了偏移量,卻記不得那是東京還是首爾;-06:00 可能來自 Central Time、Saskatchewan、Central America 之中任何一個。更麻煩的是這個偏移量在值建立的當下反映了來源時區,之後就與那個時區脫鉤了 —— 值本身不知道該地區何時進出日光節約,所以對 DateTimeOffset 做加減不會套用調整規則(見下方 TimeZoneInfo)。
TimeZoneInfo
代表時區本身,包含該地區的日光節約調整規則 —— 這是 DateTime 的 Kind 與 DateTimeOffset 的偏移量都裝不下的資訊。它沒有公開建構子,從系統時區表取得:
TimeZoneInfo cst = TimeZoneInfo.FindSystemTimeZoneById("Central Standard Time");
DateTime tokyoTime = TimeZoneInfo.ConvertTime(DateTime.Now, TimeZoneInfo.Local, tokyo);自 .NET 6 起,FindSystemTimeZoneById 在 Windows 上也接受 IANA ID(America/Los_Angeles),Linux / macOS 則走 ICU 的時區資料,所以跨平台可以用同一組 ID。這一點值得記住 —— IANA tzdb 不是非 BCL 方案才有的能力。
DST 感知的算術
TimeZoneInfo 的轉換方法(ConvertTime、ConvertTimeFromUtc / ConvertTimeToUtc)會自動套用調整規則,但算術不會有任何一個 API 幫你套。直接加減的結果是「時間點正確、但不是你要的那個當地時刻」:
// CST 在 2008-03-09 02:00 進入日光節約時間
DateTime local = new DateTime(2008, 3, 9, 1, 30, 0);
DateTimeOffset t1 = new DateTimeOffset(local, cst.GetUtcOffset(local));
DateTimeOffset wrong = t1.Add(TimeSpan.FromHours(2.5)); // 2008-03-09 04:00 -06:00預期是 05:00,拿到 04:00。正確做法是轉 UTC → 做算術 → 轉回時區:
DateTimeOffset utc = t1.ToUniversalTime() + TimeSpan.FromHours(2.5);
DateTimeOffset right = TimeZoneInfo.ConvertTime(utc, cst); // 2008-03-09 05:00 -05:00使用 TimeZoneInfo 的代價是:日期時間值與它所屬的時區沒有天然的綁定關係,你得自己用一個類別或結構把兩者存在一起 —— 而在 Web 應用裡,使用者的時區往往根本不知道。
TimeSpan
表示長度而非時間點。兩個 DateTime 或 DateTimeOffset 相減得到的就是 TimeSpan。
TimeSpan duration = new TimeSpan(1, 30, 0); // 1 小時 30 分
DateTime endTime = DateTime.Now.Add(duration);
TimeSpan difference = endTime - DateTime.Now;
Console.WriteLine($"{difference.TotalMinutes} minutes");DateOnly 與 TimeOnly
.NET 6 引入。當語意上根本沒有時區問題時(生日不會因為使用者飛到東京就變成前一天),用這兩個型別比用 DateTime 再忽略時間部分更誠實,也少一整類 off-by-one-day 的 bug。
DateOnly birthDate = new DateOnly(1990, 5, 20);
TimeOnly meetingTime = new TimeOnly(14, 30);除了時區,還有兩個實務理由:序列化時不會夾帶一個沒意義的時間(或日期)欄位去混淆資料意圖,以及 DateOnly 對得上資料庫的 date 型別。
TimeOnly 另外解掉的是拿 TimeSpan 當一天中的時刻這個舊慣例的問題:TimeSpan 表示的是經過的時間,可以是負的、上限超過 29000 年,所以 18:00 加 8 小時會得到 26:00 這種值;TimeOnly 在 24 小時內回繞,會得到 02:00。用 DateTime 硬扛也有類似的坑 —— 常見的做法是配一個 DateTime.MinValue(0001-01-01)當佔位日期,然後往前減幾小時就 OutOfRange 了。
兩個型別都不適用於 .NET Framework。
TimeProvider
.NET 8 加入,把「現在幾點」變成可注入的相依。直接呼叫 DateTime.UtcNow 的程式碼沒辦法穩定測試 —— 你對「現在」沒有任何控制權,測試會隨執行當下的時間、時區、甚至跨日而 flaky。官方舉的例子是「事件前一天發提醒」這種邏輯:過去得自己包一層抽象才測得動,現在 BCL 直接給了基底類別。
TimeProvider provider = TimeProvider.System;
DateTimeOffset currentTime = provider.GetUtcNow();測試時換上假的 TimeProvider 實作,就能把時間釘在任一時間點,或模擬時間推進。.NET Framework 與 .NET Standard 則透過 Microsoft.Bcl.TimeProvider NuGet 套件取得。
Unix timestamp 互轉
Unix timestamp 是自 1970-01-01 UTC 起算的秒數,外部 API 常用。BCL 在 DateTimeOffset 上直接提供雙向轉換 —— 注意入口與出口都是 DateTimeOffset 而非 DateTime,因為 Unix timestamp 本身就以 UTC 為基準。
DateTimeOffset fromUnix = DateTimeOffset.FromUnixTimeSeconds(1625072400);
long unixNow = DateTimeOffset.UtcNow.ToUnixTimeSeconds();閏年與年齡計算
年齡不能直接用年份相減,因為今年的生日可能還沒到;閏日生日(2/29)又讓「今年的生日」在平年不存在。用 AddYears 反推可以一次處理兩者 —— AddYears 對 2/29 會自動落到平年的 2/28。
DateTime birthday = new DateTime(2000, 2, 29);
DateTime today = DateTime.Today;
int age = today.Year - birthday.Year;
if (birthday > today.AddYears(-age)) age--;常見陷阱
儲存一律用 UTC,而且要顯式把
Kind設成DateTimeKind.Utc。存本機時間到資料庫,日光節約切換時會出現重複或不存在的時刻;而Kind是Unspecified的值連在同一台機器上反序列化都是有歧義的。需要保留當地資訊時改用DateTimeOffset,不要存本機DateTime。ToString("O")的往返要配DateTimeStyles.RoundtripKind。"O"會把Kind編碼進字串(Utc→ 結尾Z、Local→+08:00、Unspecified→ 什麼都不加),但 parse 回來時要傳RoundtripKind才會還原成原本的Kind,否則往返一趟型別對了、語意掉了。解析一定指定格式與文化。
ParseExact搭配CultureInfo.InvariantCulture讓輸入格式成為明確契約,而不是賭執行環境的地區設定。csharpDateTime parsed = DateTime.ParseExact("30-09-2023", "dd-MM-yyyy", CultureInfo.InvariantCulture);不指定的後果有兩種,糟的是第一種:
09-10-2023這種日月都 ≤ 12 的字串,預設Parse會依當前 culture 選一種讀法,成功解析成錯的日期,程式一路跑下去沒有任何徵兆;30-09-2023這種日 > 12 的則在 month-first 的 culture 下丟FormatException。同一份輸入在開發機正確、在另一台機器上錯或炸,差別只在系統的地區設定。不想丟例外時用TryParseExact接住失敗,而不是退回沒有格式約束的Parse。比較前先對齊基準。兩邊都轉成 UTC(或都轉成本機)再比,混著比的結果看起來是合法的布林值,只是答案錯的。
NodaTime
BCL 之外的選項。門檻要畫對地方:「需要時區」本身不是理由,TimeZoneInfo 就處理得了時區轉換、DST 調整規則與 IANA ID。NodaTime 賣的是型別的嚴謹度 —— 它把「本地時間 / 瞬間 / 帶時區時間」拆成不同型別(LocalDateTime / Instant / ZonedDateTime),讓 BCL 裡靠紀律維持的區分變成編譯期就分得開的東西,加上非格里曆的支援。
var clock = SystemClock.Instance.GetCurrentInstant();
DateTimeZone timeZone = DateTimeZoneProviders.Tzdb["America/New_York"];
ZonedDateTime nyTime = clock.InZone(timeZone);換句話說,值得考慮的訊號是「團隊反覆在 DateTime 該不該轉換這件事上出錯」或「要處理非格里曆」,而不是「這個專案有跨時區需求」。導入的代價是整個 domain 的型別都要換過去。
Sources
- 原始連結: https://blog.devgenius.io/working-with-date-and-time-in-net-updated-for-net-8-c66890e236db
- Local copy:
raw/Working with Date and Time in .NET (Updated for .NET 8).md - Ingested: 2026-08-16
- Local copy:
- Microsoft Learn — 日期、時間和時區(2026-08-16 交叉比對,
TimeZoneInfo、DST 感知算術、DateTimeOffset作為預設型別的建議出自此處)- https://learn.microsoft.com/zh-tw/dotnet/standard/datetime/
- https://learn.microsoft.com/zh-tw/dotnet/standard/datetime/choosing-between-datetime
- https://learn.microsoft.com/zh-tw/dotnet/standard/datetime/performing-arithmetic-operations
- https://learn.microsoft.com/zh-tw/dotnet/standard/base-types/standard-date-and-time-format-strings
- https://learn.microsoft.com/zh-tw/dotnet/api/system.timezoneinfo.findsystemtimezonebyid